iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Build on Google AI

Build on Google AI :長者照護 —— 口腔機能訓練 與 延緩認知退化系列 第 10 篇

少年,我看你面相不錯。你聽過 Google MediaPipe Face Landmarker 嗎?(下)

  • 分享至 

  • xImage
  •  

一、前言

昨天見識了俠客 Face Landmarker 的絕技三式,
府上老爺當場拍桌:「這人才,我要了!」

請高手入府,必須先張羅妥當!

先收拾一間廂房讓他住下(模型檔打包進 App),
再養幾隻信鴿,府裡有事飛鴿傳書,他收到就出手,打完再把戰報傳回來(原生端橋接)。

至於怎麼請?
江湖上有現成的牙行(社群套件)能代為引薦,也可以親自登門迎請,各有利弊,得好好掂量。

今天就來看看,如何把 Face Landmarker 請進專案!

GitHub Repo:
https://github.com/NamoAmitabhaTW/kenkou-app.git


二、Google MediaPipe Face Landmarker 導入原生端

(一)Android、iOS 模型導入檢查表

項目 檢查點 專案位置
1. 相依套件與最低系統版本 對照官方設定指南(iOS、Android)確認套件版本與最低支援版本 iOS:face_mesh.podspec;Android:build.gradle
2. 模型檔 face_landmarker.task 已打包進 App iOS:face_mesh.podspec;Android:build.gradle(模型放在 packages/face_mesh/ios/Resources,兩平台共用)
3. 載入模型 找不到模型時回報錯誤,App 不閃退 iOS:FaceMeshPlugin.swift;Android:FaceLandmarkerService.kt
4. 模型參數 設定執行模式(預設為 IMAGE);需要 blendshape 時要手動開啟;確認偵測、存在與追蹤三項信心門檻(預設皆為 0.5)。 iOS:FaceLandmarkerService.swift;Android:FaceLandmarkerService.kt
5. 宣告權限 已宣告相機權限 iOS:Info.plist;Android:AndroidManifest.xml
6. 輸入影格 影格已轉正;確認是否鏡像(會影響 blendshape 的左右);VIDEO、LIVE_STREAM 模式的時間戳要嚴格遞增 iOS:CameraSession.swift;Android:FaceLandmarkerService.kt
7. 回傳結果 沒偵測到臉時也要回報;要在畫面上疊標記時,附上影格尺寸供換算座標 iOS:FaceLandmarkerService.swift;Android:FaceLandmarkerService.kt

(二)模型導入步驟:以原生端 Android 為例

步驟 1|加入 MediaPipe:build.gradle

defaultConfig {
minSdk = 24                                   // MediaPipe Tasks 下限
consumerProguardFiles("consumer-rules.pro")   // R8 規則
}

dependencies {
def camerax_version = "1.4.2"
implementation("com.google.mediapipe:tasks-vision:<專案使用的版本>")
implementation("androidx.camera:camera-core:$camerax_version")
implementation("androidx.camera:camera-camera2:$camerax_version")
implementation("androidx.camera:camera-lifecycle:$camerax_version")
implementation("androidx.camera:camera-view:$camerax_version")
}

坑點:debug 版正常,release 版閃退
Flutter 的 release 版預設會開啟 R8。R8 會刪掉「看起來沒用到」的類別,並把類別改成很短的名字。MediaPipe 有些地方是用名字找類別的,包括 JNI、protobuf 反射、Flogger,R8 看不到這些用法,App 就會在執行時閃退。要自己加:

# consumer-rules.pro
-keep class com.google.mediapipe.** { *; }
-keep class com.google.protobuf.** { *; }
-keep class com.google.common.flogger.** { *; }
-dontwarn com.google.mediapipe.**
-dontwarn com.google.protobuf.**
-dontwarn com.google.common.flogger.**

步驟 2|打包模型檔:build.gradle

sourceSets {
main.java.srcDirs += "src/main/kotlin"
// 模型直接用 iOS 那份
main.assets.srcDirs += "../ios/Resources"
}

步驟 3|載入模型:FaceLandmarkerService.kt

private fun loadModel(context: Context): ByteBuffer? = try {
context.assets.open(MODEL_ASSET).use { input ->    // MODEL_ASSET = "face_landmarker.task"
val bytes = input.readBytes()
ByteBuffer.allocateDirect(bytes.size)
.order(ByteOrder.nativeOrder())
.put(bytes)
.apply { rewind() }
}
} catch (e: IOException) {
null   // 呼叫端轉成 no_model 回報給 Dart
}

步驟 4|初始化:FaceLandmarkerService.kt

參數和 iOS 完全相同,見 Day8 參數表。

fun create(context: Context): FaceLandmarkerService {
    val model = loadModel(context) ?: throw ModelMissing()

    lateinit var service: FaceLandmarkerService
    val options = FaceLandmarker.FaceLandmarkerOptions.builder()
        .setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).build())
        .setRunningMode(RunningMode.LIVE_STREAM)
        .setNumFaces(1)
        .setOutputFaceBlendshapes(true)
        .setMinFaceDetectionConfidence(0.5f)
        .setMinFacePresenceConfidence(0.5f)
        .setMinTrackingConfidence(0.5f)
        .setResultListener { result, input -> service.handle(result, input) }   // LIVE_STREAM 一定要設
        .setErrorListener { e -> Log.w(TAG, "FaceLandmarker 錯誤: $e") }
        .build()

    service = FaceLandmarkerService(FaceLandmarker.createFromOptions(context, options))
    return service
}

步驟 5|宣告相機權限:AndroidManifest.xml

這份 manifest 在 build 時會自動合併進 App,App 自己的 manifest 不用改。

<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />

步驟 6|設定相機並送出影格:CameraSession.kt、FaceLandmarkerService.kt

// CameraSession.kt
return ImageAnalysis.Builder()
    .setResolutionSelector(resolution)   // 鎖 4:3、目標 640×480
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)   // 處理不完就丟舊的
    .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)  // 直接要 RGBA
    .build()
    .also { analysis ->
        analysis.setAnalyzer(analysisExecutor) { image ->
            val handler = onFrame
            if (handler != null) handler(image) else image.close()   // 沒人處理也要 close
        }
    }
// FaceLandmarkerService.kt
@Synchronized
fun detect(image: ImageProxy) {
    // …省略已關閉時的檢查

    // 時間戳
    val timestampMs = image.imageInfo.timestamp / 1_000_000
    if (timestampMs <= lastTimestampMs) {   
        image.close()
        return
    }
    lastTimestampMs = timestampMs

    val rotation = image.imageInfo.rotationDegrees
    val raw = image.use { it.toBitmap() }   

    try {
        landmarker.detectAsync(upright(raw, rotation), timestampMs)
    } catch (e: Exception) {
        Log.w(TAG, "detectAsync 失敗: $e")
    }
}

private fun upright(bitmap: Bitmap, rotationDegrees: Int): MPImage {   
    val rotated = if (rotationDegrees == 0) {
        bitmap
    } else {
        val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
        Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    }
    return BitmapImageBuilder(rotated).build()
}

兩個坑要注意:

  • 每張 ImageProxy 用完都要 close。 不然 CameraX 不會送下一張影格。
  • 預覽和分析兩邊都要鎖 4:3。 兩者會各自選解析度,比例不一致的話,畫在臉上的標記會整片偏掉。

步驟 7|接收結果:FaceLandmarkerService.kt

邏輯和 iOS 相同(見 Day8 的(五)):沒偵測到臉也要回報,有臉的話就取出 blendshape 和需要的點位,連同影格尺寸打包成 FaceFrame。

// 照 CONTOUR_INDICES 點名,只取用得到的點
// 每個點的 x、y 攤平成一維:[x0, y0, x1, y1, ...]
val contour = FloatArray(CONTOUR_INDICES.size * 2)
CONTOUR_INDICES.forEachIndexed { i, index ->
    contour[i * 2] = landmarks[index].x()
    contour[i * 2 + 1] = landmarks[index].y()
}

要特別注意:CONTOUR_INDICES 的點位索引和順序,必須和 iOS 的 contourIndices 完全相同。Dart 端是照固定位移切資料的,兩邊只要差一個點,Dart 切出來的嘴唇和臉頰就會錯位。


三、Flutter橋接原生端

(一)Android、iOS 橋接檢查表(項目、檢查點、專案位置)

項目 檢查點 專案位置
1. 註冊 plugin plugin 的 pubspec 宣告各平台的進入類別(Android 另需 package);App 的 pubspec 引入 plugin plugin:pubspec.yaml;App 端:pubspec.yaml
2. 原生端通道 通道名稱與 Dart 端一字不差(含 PlatformView 的 viewType) iOS:FaceMeshPlugin.swift;Android:FaceMeshPlugin.kt
3. 取得 Activity Android 的 plugin 實作 ActivityAware,Activity 結束時停止相機 Android:FaceMeshPlugin.kt
4. 請求相機權限 執行期能跳出權限詢問並收到結果;Android 的 request code 在 16 bit 以內 iOS:CameraSession.swift;Android:FaceMeshPlugin.kt
5. 處理 start 兩平台的方法名稱、參數、錯誤碼一致;未知方法回傳 NotImplemented iOS:FaceMeshPlugin.swift;Android:FaceMeshPlugin.kt
6. 在主執行緒推送結果 兩平台的事件欄位一致;陣列用 Float32List 等 typed data 傳送,點位順序(見第二章步驟 7)和 Dart 端一致;呼叫 EventSink 前切回主執行緒 iOS:FaceMeshPlugin.swift;Android:FaceMeshPlugin.kt
7. 嵌入相機預覽 兩平台預覽的鏡像與縮放方式一致,疊圖座標才能共用;Android 的 PreviewView 要設為 COMPATIBLE iOS:PreviewView.swift;Android:PreviewView.kt
8. Dart 端通道 預覽依平台使用 AndroidView 或 UiKitView;直接使用 receiveBroadcastStream,不再另外套 asBroadcastStream Dart:face_mesh.dart

(二)橋接步驟:以原生端 Android 為例

Day7 我們看過原生 iOS 如何把 Face Landmarker 的輸出結果打包,再過橋送到 Flutter;今天換 Android。兩個平台的通道名稱、參數、錯誤碼、事件欄位完全一致,所以 Dart 端只要寫一份。

步驟 1|註冊 plugin:pubspec.yaml

# plugin 端:packages/face_mesh/pubspec.yaml

flutter:
  plugin:
    platforms:
      android:
        package: tw.aarontsai.face_mesh
        pluginClass: FaceMeshPlugin
      ios:
        pluginClass: FaceMeshPlugin
# App 端 pubspec.yaml
dependencies:
  face_mesh:
    path: packages/face_mesh

步驟 2|建立三條通道:FaceMeshPlugin.kt

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    // MethodChannel:一次性的指令(開始/停止)
    methodChannel = MethodChannel(binding.binaryMessenger, "face_mesh/method")
    methodChannel.setMethodCallHandler(this)

    // EventChannel:原生端持續推送的偵測結果
    eventChannel = EventChannel(binding.binaryMessenger, "face_mesh/events")
    eventChannel.setStreamHandler(this)

    // PlatformView:相機預覽
    binding.platformViewRegistry.registerViewFactory("face_mesh/preview", PreviewViewFactory())
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    stop()
    methodChannel.setMethodCallHandler(null)
    eventChannel.setStreamHandler(null)
}

步驟 3|取得 Activity:FaceMeshPlugin.kt

這是 iOS 沒有的步驟。
CameraX 必須綁定在一個 LifecycleOwner 上,跳出權限詢問也需要 Activity,所以 plugin 要實作 ActivityAware 來拿到 Activity。App 的 MainActivity 繼承的 FlutterActivity 本身就是 LifecycleOwner。

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
    activityBinding = binding
    binding.addRequestPermissionsResultListener(this)
}

override fun onDetachedFromActivity() {
    // 相機綁在這個 Activity 的 lifecycle 上,Activity 走了就一起收掉
    stop()
    activityBinding?.removeRequestPermissionsResultListener(this)
    activityBinding = null
    pendingPermission = null
}

override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity()

override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) =
    onAttachedToActivity(binding)

步驟 4|請求相機權限:FaceMeshPlugin.kt

iOS 的 requestAccess 會直接給 callback。Android 的權限結果則是回到 Activity,只有 plugin 收得到,所以這段邏輯寫在 plugin 裡:

private fun requestCameraPermission(activity: Activity, completion: (Boolean) -> Unit) {
    val permission = Manifest.permission.CAMERA
    if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
        completion(true)
        return
    }
    // 上一次的詢問還沒回來(例如使用者連點兩次),不要疊第二個系統對話框
    if (pendingPermission != null) {
        completion(false)
        return
    }
    pendingPermission = completion
    ActivityCompat.requestPermissions(activity, arrayOf(permission), CAMERA_REQUEST_CODE)
}

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray,
): Boolean {
    if (requestCode != CAMERA_REQUEST_CODE) return false
    val completion = pendingPermission ?: return true
    pendingPermission = null
    completion(grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED)
    return true
}

步驟 5|處理 start:FaceMeshPlugin.kt

start 的順序是「權限 → 模型 → 相機」,每個環節失敗都回傳對應的錯誤碼:

// …省略:取得 activity、owner

requestCameraPermission(activity) { granted ->
    if (!granted) {
        result.error("camera_failed", "使用者未授權相機權限", null)
        return@requestCameraPermission
    }

    if (detect) {
        val service = try {
            FaceLandmarkerService.create(activity)
        } catch (e: FaceLandmarkerService.ModelMissing) {
            result.error("no_model", "找不到 face_landmarker.task,請確認 assets 有被打包進去", null)
            return@requestCameraPermission
        } catch (e: Exception) {
            result.error("landmarker_failed", "MediaPipe FaceLandmarker 初始化失敗", e.message)
            return@requestCameraPermission
        }
        service.onResult = ::emit
        this.service = service
        CameraSession.onFrame = service::detect
    } else {
        CameraSession.onFrame = null   // detect = false:只開預覽,不載入模型
    }

    CameraSession.start(activity, owner, analyze = detect) { error ->
        if (error != null) {
            stop()   // 失敗就立刻釋放 MediaPipe 的 native 記憶體
            result.error("camera_failed", error, null)
        } else {
            running = true
            detecting = detect
            result.success(null)
        }
    }
}

步驟 6|在主執行緒推送結果:FaceMeshPlugin.kt

private fun emit(frame: FaceFrame?) {
    // MediaPipe 的 callback 不在 main thread,但 EventSink 必須在 main thread 呼叫
    mainHandler.post {
        val sink = eventSink ?: return@post

        if (frame == null) {
            sink.success(mapOf("hasFace" to false))   // 沒偵測到臉也回報
            return@post
        }

        sink.success(
            mapOf(
                "hasFace" to true,
                "blendshapes" to frame.blendshapes,
                "yaw" to frame.yaw,
                "rollDegrees" to frame.rollDegrees,
                "mouthOpenRatio" to frame.mouthOpenRatio,
                "mouthWidthRatio" to frame.mouthWidthRatio,
                "contour" to frame.contour,           // FloatArray 會自動編成 Dart 的 Float32List
                "imageWidth" to frame.imageWidth,
                "imageHeight" to frame.imageHeight,
            )
        )
    }
}

步驟 7|嵌入相機預覽:PreviewView.kt

internal class PreviewPlatformView(context: Context) : PlatformView {

    private val previewView = PreviewView(context).apply {
        setBackgroundColor(Color.BLACK)
        scaleType = PreviewView.ScaleType.FILL_CENTER                    // 等同 iOS 的 resizeAspectFill
        implementationMode = PreviewView.ImplementationMode.COMPATIBLE   // 最容易踩的坑
    }

    init {
        CameraSession.attach(previewView)
    }

    override fun getView(): View = previewView

    override fun dispose() {
        CameraSession.detach(previewView)
    }
}

一定要用 COMPATIBLE:
預設的 PERFORMANCE 模式會優先使用 SurfaceView。AndroidView 是把原生 View 畫到 Flutter 的 texture 上再合成,而 SurfaceView 的畫面在另一個獨立的 surface 上,不會被畫進去,所以只看得到黑畫面。部分裝置會自動退回 TextureView,所以常見的狀況是有些手機正常、有些黑畫面。改成 COMPATIBLE 就一律使用 TextureView。

步驟 8|Dart 端通道 :face_mesh.dart

// face_mesh.dart
class FaceMesh {
  static const MethodChannel _method = MethodChannel('face_mesh/method');
  static const EventChannel _events = EventChannel('face_mesh/events');

  static Future<void> start({bool detect = true}) =>
      _method.invokeMethod<void>('start', {'detect': detect});

  static Future<void> stop() => _method.invokeMethod<void>('stop');

  static Stream<FaceFrame> get frames => _events
      .receiveBroadcastStream()
      .map((event) => FaceFrame.fromMap(event as Map<Object?, Object?>));
}

預覽依平台選擇對應的 view,viewType 要和步驟 2 註冊的名稱相同:

if (defaultTargetPlatform == TargetPlatform.android) {
  return const AndroidView(
    viewType: 'face_mesh/preview',
    creationParamsCodec: StandardMessageCodec(),
  );
}
return const UiKitView(
  viewType: 'face_mesh/preview',
  creationParamsCodec: StandardMessageCodec(),
);

四、Flutter社群套件

(一)簡介

Google 官方目前沒有提供 Face Landmarker 的 Flutter 套件,所以在 Flutter 使用 Face Landmarker 有兩種做法:使用社群套件,或自己橋接原生端。

pub.dev 上較具代表性的套件:

套件 做法 平台 Blendshape
mediapipe_face_mesh 內建 TFLite,直接執行 MediaPipe 模型 Android、iOS、Windows 有
face_detection_tflite 內建 TFLite,直接執行 MediaPipe 模型 Android、iOS、macOS、Windows、Linux、Web 有
google_mlkit_face_detection Google ML Kit(非 MediaPipe) Android、iOS 無

(二) 社群套件 vs. 自己橋接

兩者最大的差別在相機影格的傳遞路徑:

  • 社群套件:多半搭配 camera 套件使用,每張影格會先複製進 Dart,再交給模型。
  • 自己橋接:影格可以留在原生端,只把結果傳進 Dart。

在 Android 實機(Samsung Galaxy A70)上實測:

  • camera 套件以中等畫質(720×480)串流時,每張影格約 0.74 MB,每秒約 30 張,合計約 22 MB 的影格資料傳進 Dart。
  • 本專案每張影格的結果整包約 1.8 KB(其中臉部特徵點約 0.7 KB),每秒約 21 張,合計約 40 KB。
社群套件 自己橋接
開發成本 低,加入套件即可使用 高,兩個平台各寫一套原生程式
影格路徑 先複製進 Dart 留在原生端
可控性 只能用套件開放的選項 輸出內容、鏡像、解析度都可自訂
維護 依賴套件作者 自行維護

五、小結

今天我們一起迎請了俠客 Face Landmarker ,
從模型導入再到橋接原生端,也將過程以檢查表、步驟的方式寫在文章中。
希望我們都能對於實作的過程與步驟更熟悉!


六、預告

明天就讓我們進入 Google MediaPipe Face Landmarker 系列的最後一篇!

感謝有緣看到這邊的你~
希望佛菩薩也祝福你:🌟幸福快樂 健康自在🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️


上一篇
少年,我看你面相不錯。你聽過 Google MediaPipe Face Landmarker 嗎?(中)
下一篇
Flutter : Google MediaPipe Face Landmarker x App 開發實務
系列文
Build on Google AI :長者照護 —— 口腔機能訓練 與 延緩認知退化 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言